It all started with a perfectly innocent question: “Hey! You teach PHP, don’t you? You probably know a couple of good PHP devs, right?” And as a matter of fact, I do. Actually, I know quite a lot of them… well, perhaps know is a little too big of a word, let’s just say I have access to many. The first few times I’d simply reply with: “Gee… I don’t really know of anyone in particular but if you want I’m ok with sharing your message with my 1000+ subscribers on your behalf”. Needless to say, the answer was always some variation of “Awesome!”.
So there I was with yet another task on my to-do… And let me tell you, I was not exactly idle to begin with. Move forward a couple of months and I had a choice: I could have ignored the requests altogether or do the honorable thing and build myself a little script that could take care of the heavy lifting for me. And, as you might imagine since you’re reading this, I took the second route. And it was kind of fun too.
I’m not going to go into all of the details of setting up a Google Form, a Mailchimp template, and all of those boring parts and jump straight into the juicy part: how I put it all together into a nice CLI tool that can be easily mounted upon a cronjob and make the world a better place.
IPC NEWSLETTER
All news about PHP and web development
Before we go any further, here’s the project’s repository for your reference. Start by cloning it so you can follow along:
git clone https://github.com/mchojrin/job_offers.git
Tools featured in this post
To avoid annoying inconsistencies between deployment environments I used Docker to keep everything within a container, meaning, in order to run the application on your machine, you’ll need it installed. Another tool I used for making the installation process as smoothly as possible is make.
And I believe that pretty much covers the basics needed to get started.
The workflow is this:
- Someone looking to hire a PHP developer will fill a form on my website
- The form is connected to a cloud based spreadsheet
- The main script
./run.sh
is called
The script in turn brings up the docker image and runs a Symfony Command.
How to run the application
If you’ve cloned the repository and have Docker installed, these are the steps you need to follow to have the application run:
- Get your Mailchimp API-Key from your account:
- Get your API-Key from your Google account
- Save the information from Google into credentials.json
sudo make
sudo make install
cp .env.sample .env
- Edit the
.env
file and put your information in there ./run.sh
But what’s inside the black box?
The entry point
Going from the outside in you’ll find the entry point to the application atrun.php
. This is just a bootstrap script. It basically takes care of initializing the objects that will be needed to perform the actual tasks. The most important part of it is the creation of the SendWeeklyJobOffersCommand object where the main control logic lays.
If you open the file src/Command/SendWeeklyJobOffersCommand.php
you’ll immediately realize it’s a subclass of Symfony\Component\Console\Command\Command
which is specifically designed to make interaction with the O.S. console really simple.
In general, a Symfony application will potentially have a set of commands. This is not the case for this one. In fact, this isn’t really a typical Symfony application but rather an application that builds upon some of Symfony’s components. That’s why you’ll find this line at run.php
:
$app->setDefaultCommand($theCommand->getName(), true);
Which informs the Application class about the fact that this command will be the default and only one (Signaled by the true value for the second parameter).
If you look at how $theCommand
is created:
$theCommand = new SendWeeklyJobOffersCommand( $jobOfferRepository, $campaignManager, $templateRenderer, [ 'subject' => $_ENV['MAILCHIMP_SUBJECT'], 'fromName' => $_ENV['MAILCHIMP_FROM_NAME'], 'title' => $_ENV['MAILCHIMP_TITLE'], 'replyTo' => $_ENV['MAILCHIMP_REPLY_TO'], ] );
You’ll note how every dependency is injected into it, making the command more testable and maintainable. Note how Symfony\Component\Dotenv\Dotenv is used to handle environment specific configuration, such as API Keys. With everything in place, the application will effectively perform its duty when the run method is invoked. The natural next step in our journey is the SendWeeklyJobOffersCommand class. Let’s take a closer look at it.
The Command
The constructor simply takes the dependencies from the outside world and stores them in private variables to be used later.
The configure method is invoked automatically by Symfony at the application initialization phase. In general, this method serves as a placeholder for basic command configuration options (Command line arguments and modifiers). Finally, the actual tasks are performed via the execute method.
This method takes two arguments provided by Symfony: an InputInterface object and an OutputInterface one. These arguments are an abstraction over the script’s input and output stream respectively.
In this line:
$jobOffers = $this->jobOfferRepository->getCurrentWeekPosts();
The contents of the email are created using the services provided by the templateRenderer (In our case an instance of Twig, Symfony’s default template rendering engine). Then, after configuring the campaignManager
object, the email is sent using:
$this->campaignManager->send($html);
As you can see, the Command object doesn’t do much on its own. It basically acts as a controller, orchestrating the whole workflow but delegating the specific parts to specialized objects. These helper objects are called Services in Symfony’s terminology.
Ready to take it even a level deeper? Great. Let’s have a look at the services.
The Services
There are three main services used in this application:
- \App\Campaign\Manager
- \App\Repository\JobOfferRepository
- \App\Template\TwigRenderer
Before going through the specific details of each one, note how the SendWeeklyJobOffersCommand’s constructor declares its arguments in terms of interfaces instead of classes.
This has the goal of reducing the application’s coupling, meaning that concrete implementations may change over time (Or most likely at test time) and nothing should break in the meantime.
Of course, this also means that each one of the concrete service classes must implement the appropriate interface, hence the need to define classes such as TwigRenderer this way:
<?php namespace App\Template; use Twig\Environment; class TwigRenderer extends Environment implements RendererInterface { }
Effectively creating a little wrapper around the Twig\Environment.
The other two services follow a similar pattern< (\App\Repository\JobOfferRepository implements \App\Repository\JobOfferRepositoryInterface
and \App\Campaign\Manager
implements \App\Campaign\ManagerInterface)
.
Next, we’ll have a look at \App\Campaign\Manager
. This class is responsible for the interaction with the email marketing vendor, in this case, Mailchimp.
If you go through the methods found in this class you’ll notice that there’s no single specific reference to Mailchimp. That is by design. Should the case come to migrate to a different vendor (ConvertKit for instance), the impact on the application should be kept to a minimum. This architecture achieves that goal as we’ll soon discuss.
In fact, if you pay close attention, you’ll notice that the heavy lifting is delegated to an instance of \App\Campaign\ApiClientInterface
which is implemented by \App\Campaign\MailchimpAPIClient
(A class that builds upon the standard \MailchimpAPI\Mailchimp)
, and here’s where things get concrete.
By creating this structure, it’d also be easy to switch to a different base class if, for instance, a better implementation of the Mailchimp API became available.
Now that we covered the Mailchimp side, let’s focus our attention on the spreadsheet processing.
For this, the starting point is the class \App\Repository\JobOfferRepository
.
The first thing you’ll see is that this class implements \App\Repository\JobOfferRepositoryInterface
. The idea here is to abstract the specific storage of JobOffer data as much as possible, leaving the possibility of using different mediums, like databases, text files, etc, open.
In this particular case, the class is tied to an underlying online spreadsheet, hence the parameters found in the constructor.
Looking back at the code, it would probably make sense to implement an intermediate class called for the Storage… I’ll leave that as homework for you.
In the current implementation, you can see how the same pattern is followed: the class simply has the logic for extracting from storage the exact information that is of interest to the main program, leaving the details of how to interact with the spreadsheet to a helper object, an instance of \App\SpreadSheet\ReaderInterface
.
The class\App\SpreadSheet\GoogleSpreadSheetReader
is the implementation used in this case.
This class is responsible for actually interacting with Google’s API, effectively relying on the classes provided by Google’s SDK(\Google\Client and Google\Service)
.
Conclusion
In this article, you learned how to create a Single Command Application using Symfony and how to structure your code in a way that is flexible enough to make it future proof. Hopefully some of the ideas shared here will help you in your day-to-day. And finally, if you want to contribute to the improvement of the project, even if it’s just for practicing, feel free to open a PR!